MockManager in unit tests - a builder pattern used for mocks
A few years ago I wrote about this, but in less detail. Here is a more refined version of the same idea.
Intro
Unit tests are both boon and bane to developers. They allow quick testing of functionality, readable examples of use, fast experimentation of scenarios for just the components involved. But they also can become messy, need maintenance and update with every code change and, when done lazily, can't hide bugs rather than reveal them.
I think the reason unit testing is so difficult is because it's associated with testing, something other than code writing, and also that unit tests are written in a way opposite most other code we write.
In this post I will give you a simple pattern of writing unit tests that will enhance all the benefits, while eliminating most of the cognitive dissonance with normal code. Unit tests will remain readable and flexible, while reducing duplicate code and adding no extra dependencies.
How to unit test
But first, let's define a good unit test suite.
To properly test a class, it has to be written in a certain way. In this post we will cover classes using constructor injection for dependencies, which is my recommended way of doing dependency injection.
Then, in order to test it, we need to:
- cover positive scenarios - when the class does what it's supposed to do, with various combinations of setup and input parameters to cover the whole functionality
- cover negative scenarios - when the class fails in the correct way when the setup or input parameters are wrong
- mock all external dependencies
- keep all of the test setup, action and assertion in the same test (what is normally called the Arrange-Act-Assert structure)
But that's easier said than done, because it also implies:
- setting up the same dependencies for every test, thus copying and pasting a lot of code
- setting up very similar scenarios, with just one change between two tests, again repeating a lot of code
- generalizing and encapsulating nothing, which is what a dev would normally do in all of their code
- writing a lot of negative cases for few positive cases, which feels like having more testing code than functional code
- having to update all of these tests for every change to the tested class
Who loves that?
Solution
The solution is to use the builder software pattern to create fluid, flexible and readable tests in the Arrange-Act-Assert structure, while encapsulating setup code in a class complementing the unit test suite for a specific service. I call this the MockManager pattern.
Let's start with a simple example:
// the tested class
public class Calculator
{
private readonly ITokenParser tokenParser;
private readonly IMathOperationFactory operationFactory;
private readonly ICache cache;
private readonly ILogger logger;
public Calculator(
ITokenParser tokenParser,
IMathOperationFactory operationFactory,
ICache cache,
ILogger logger)
{
this.tokenParser = tokenParser;
this.operationFactory = operationFactory;
this.cache = cache;
this.logger = logger;
}
public int Calculate(string input)
{
var result = cache.Get(input);
if (result.HasValue)
{
logger.LogInformation("from cache");
return result.Value;
}
var tokens = tokenParser.Parse(input);
IOperation operation = null;
foreach(var token in tokens)
{
if (operation is null)
{
operation = operationFactory.GetOperation(token.OperationType);
continue;
}
if (result is null)
{
result = token.Value;
continue;
}
else
{
if (result is null)
{
throw new InvalidOperationException("Could not calculate result");
}
result = operation.Execute(result.Value, token.Value);
operation = null;
}
}
cache.Set(input, result.Value);
logger.LogInformation("from operation");
return result.Value;
}
}
This is a calculator, as is tradition. It receives a string and returns an integer value. It also caches the result for a specific input, and logs some stuff. The actual operations are being abstracted by IMathOperationFactory and the input string is translated into tokens by an ITokenParser. Don't worry, this is not a real class, just an example. Let's look at a "traditional" test:
[TestMethod]
public void Calculate_AdditionWorks()
{
// Arrange
var tokenParserMock = new Mock<ITokenParser>();
tokenParserMock
.Setup(m => m.Parse(It.IsAny<string>()))
.Returns(
new List<CalculatorToken> {
CalculatorToken.Addition, CalculatorToken.From(1), CalculatorToken.From(1)
}
);
var mathOperationFactoryMock = new Mock<IMathOperationFactory>();
var operationMock = new Mock<IOperation>();
operationMock
.Setup(m => m.Execute(1, 1))
.Returns(2);
mathOperationFactoryMock
.Setup(m => m.GetOperation(OperationType.Add))
.Returns(operationMock.Object);
var cacheMock = new Mock<ICache>();
var loggerMock = new Mock<ILogger>();
var service = new Calculator(
tokenParserMock.Object,
mathOperationFactoryMock.Object,
cacheMock.Object,
loggerMock.Object);
// Act
service.Calculate("");
//Assert
mathOperationFactoryMock
.Verify(m => m.GetOperation(OperationType.Add), Times.Once);
operationMock
.Verify(m => m.Execute(1, 1), Times.Once);
}
Let's unpack it a little. We had to declare a mock for every constructor dependency, even if we don't actually care about the logger or the cache, for example. We also had to set up a mock method that returns another mock, in the case of the operation factory.
In this particular test we wrote mostly setup, one line of Act and two lines of Assert. Moreover, if we want to test how the cache works inside the class we would have to copy paste the entire thing and just change the way we setup the cache mock.
And there are the negative tests to consider. I've seen many a negative test doing something like: "setup just what is supposed to fail. test that it fails", which introduces a lot of problems, mainly because it might fail for completely different reasons and most of the time these tests are following the internal implementation of the class rather than its requirements. A proper negative test is actually a fully positive test with just one wrong condition. Not the case here, for simplicity.
So, without further ado, here is the same test, but with a MockManager:
[TestMethod]
public void Calculate_AdditionWorks_MockManager()
{
// Arrange
var mockManager = new CalculatorMockManager()
.WithParsedTokens(new List<CalculatorToken> {
CalculatorToken.Addition, CalculatorToken.From(1), CalculatorToken.From(1)
})
.WithOperation(OperationType.Add, 1, 1, 2);
var service = mockManager.GetService();
// Act
service.Calculate("");
//Assert
mockManager
.VerifyOperationExecute(OperationType.Add, 1, 1, Times.Once);
}
Unpacking, there is no mention of cache or logger, because we don't need any setup there. Everything is packed and readable. Copy pasting this and changing a few parameters or some lines is no longer ugly. There are three methods executed in Arrange, one in Act and one in Assert. Only the nitty gritty mocking details are abstracted away: there is no mention of the Moq framework here. In fact, this test would look the same regardless of the mocking framework one decides to use.
Let's take a look at the MockManager class. Now this will appear complicated, but remember that we only write this once and use it many times. The whole complexity of the class is there to make unit tests readable by humans, easily to understand, update and maintain.
public class CalculatorMockManager
{
private readonly Dictionary<OperationType,Mock<IOperation>> operationMocks = new();
public Mock<ITokenParser> TokenParserMock { get; } = new();
public Mock<IMathOperationFactory> MathOperationFactoryMock { get; } = new();
public Mock<ICache> CacheMock { get; } = new();
public Mock<ILogger> LoggerMock { get; } = new();
public CalculatorMockManager WithParsedTokens(List<CalculatorToken> tokens)
{
TokenParserMock
.Setup(m => m.Parse(It.IsAny<string>()))
.Returns(
new List<CalculatorToken> {
CalculatorToken.Addition, CalculatorToken.From(1), CalculatorToken.From(1)
}
);
return this;
}
public CalculatorMockManager WithOperation(OperationType operationType, int v1, int v2, int result)
{
var operationMock = new Mock<IOperation>();
operationMock
.Setup(m => m.Execute(v1, v2))
.Returns(result);
MathOperationFactoryMock
.Setup(m => m.GetOperation(operationType))
.Returns(operationMock.Object);
operationMocks[operationType] = operationMock;
return this;
}
public Calculator GetService()
{
return new Calculator(
TokenParserMock.Object,
MathOperationFactoryMock.Object,
CacheMock.Object,
LoggerMock.Object
);
}
public CalculatorMockManager VerifyOperationExecute(OperationType operationType, int v1, int v2, Func<Times> times)
{
MathOperationFactoryMock
.Verify(m => m.GetOperation(operationType), Times.AtLeastOnce);
var operationMock = operationMocks[operationType];
operationMock
.Verify(m => m.Execute(v1, v2), times);
return this;
}
}
All of the required mocks for the test class are declared as public properties, allowing any customization of a unit test. There is a GetService method, which will always return an instance of the tested class, with all of the dependencies fully mocked. Then there are With* methods which atomically set up various scenarios and always return the mock manager, so that they can be chained. You can also have specific methods for assertion, although in most cases you will be comparing some output with an expected value, so these are here just to abstract away the Verify method of the Moq framework.
Conclusion
This pattern now aligns test writing with code writing:
- abstract the things you don't care about in any context
- write once and use many times
- humanly readable, self documenting code
- small methods with low cyclomatic complexity
- intuitive code writing
Writing a unit test now is trivial and consistent:
- instantiate the mock manager of the class you want to test (or write one based on the steps above)
- compose specific scenarios for the test (with auto complete for existing already covered scenario steps)
- execute the method you want to test with test parameters
- check everything is as expected
The abstraction doesn't stop at the mocking framework. The same pattern can be applied in every programming language! The mock manager construct will be very different for TypeScript or JavaScript or something else, but the unit test would pretty much look the same way.
Hope this helps!